fix(http): bridge ChatGPT MCP connector + Claude confidential-client OAuth#746
fix(http): bridge ChatGPT MCP connector + Claude confidential-client OAuth#746panda850819 wants to merge 2 commits into
Conversation
Local debug instrumentation from initial bridge work; hardcoded path to ~/.gbrain/logs/edge-trace.log makes the diff non-upstreamable and the sync I/O sits on the hot request path. Dev tree is the production deploy for this fork, so temporary console.error is sufficient when re-debugging. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
b764e88 to
504f22b
Compare
|
Force-pushed to rebase onto v0.31.10. Conflict was minor — only the post- Also removed the dev-only Related ChatGPT-MCP product-line context (not part of this PR, just to surface the cluster):
Happy to split the ChatGPT shim into a separate |
|
Closing — superseded upstream. The ChatGPT MCP connector + Claude confidential-client OAuth bridge has been absorbed by #776 (v0.31.1.1-fixwave, merged 2026-05-10) and the subsequent public-client/OAuth rework in #909 (v0.34.1). serve-http.ts + oauth-provider.ts on master now cover this path. No longer needed. |
ChatGPT's Custom MCP Connector and Claude (
client_secret_post) OAuth both fail end-to-end againstgbrain serve --http --enable-dcrtoday. Twelve targeted patches inserve-http.ts+oauth-provider.tsclose the gaps.Hit while wiring a self-hosted gbrain MCP server (v0.30.0) into ChatGPT's Custom Connector and verifying Claude.ai web + Claude Code stayed working. Each patch was triaged from real edge traces + Postgres
oauth_clients/oauth_codes/oauth_tokensintrospection rather than guessing from client-side error messages — those messages are systematically misleading (one root cause cycled through "doesn't support DCR" → "DCR endpoint 404" → "doesn't implement OAuth" → "invalid_mcp_response 405").Verified end-to-end: ChatGPT lists
search+fetchand successfully callssearchagainst the brain; Claude.ai web + Claude Code DCR + token exchange +tools/listall green.Happy to split this into three PRs if you'd prefer (oauth security / chatgpt compat / search-fetch shim) — flagged the natural split below.
What's actually landing
1. OAuth correctness (every confidential or PKCE-only DCR client)
/tokenpre-hash middleware. SDKclientAuth.js:45strict-comparesclient.client_secretreturned byclientsStore.getClient()to the request body'sclient_secret. gbrain'soauth_clients.client_secret_hashcolumn stores sha256 hex; the client holds the plaintext returned at DCR time. The literal string compare always fails forclient_secret_postclients (Claude.ai web, Claude Code) → everyauthorization_code/refresh_tokenexchange gets400 invalid_client: \"Invalid client_secret\". SHA-256 the request's plaintext before SDK clientAuth runs so the comparison is hash-vs-hash. Skip forclient_credentialsgrant — gbrain's own handler hashes itself and would double-hash.getClient()stripsclient_secretfornoneclients. Same SDK clientAuth path: it demands a secret wheneverclient.client_secretis truthy, regardless oftoken_endpoint_auth_method. PKCE-only public clients (ChatGPT, mcporter, Hermes Agent, codex-cli) registered withnonetherefore get rejected with\"Client secret is required\". Hide the stored hash so SDK falls through to the PKCE-only path. (Durable fix:registerClientshould not generate or store a secret fornoneclients in the first place — left for a follow-up.)2. ChatGPT MCP connector compatibility
Catch-all
.well-knownrewrite middleware. ChatGPT exhaustively probes nine metadata URL variants before considering discovery complete:Any 404 makes ChatGPT abort and surface a misleading "DCR endpoint 404". Single regex rewrites every variant onto the SDK's canonical paths so the same metadata body answers every probe — beats enumerated alias whack-a-mole.
resourceServerUrl: '/mcp'so PRMresourcematches the URL users enter. Both confirmed-working open-source ChatGPT MCP connector references (tae0y/real-estate-mcp + Auth0, GetLarge fastify-mcp + Ory Hydra) publish PRMresourceas the/mcpURL. With this set, SDK serves PRM bodyresource: \"https://<host>/mcp\".UA-conditional OIDC stub fields. OpenAI's Apps SDK auth doc says ChatGPT accepts OAuth 2.0 metadata or OIDC metadata. Empirically, ChatGPT silently aborts DCR if the AS metadata document lacks
subject_types_supported,id_token_signing_alg_values_supported,userinfo_endpoint,jwks_uri— both confirmed-working references are full OIDC providers, not coincidence. Inject these fields viares.jsonpatch, gated onUser-Agentmatching/aiohttp|openai-mcp/iso non-ChatGPT clients keep clean OAuth 2.1 metadata. SDK's metadata document is a shared singleton, so we clone-before-mutate (otherwise one ChatGPT request would leak OIDC fields into every subsequent client's response)./userinfoand/.well-known/jwks.jsonstub routes back the OIDC pointers without changing token semantics. Userinfo returns soft 200 with{ sub: \"anonymous\" }(401 reads as auth failure to ChatGPT and aborts token exchange); jwks returns{ keys: [] }since gbrain uses opaque tokens, not JWTs.WWW-Authenticateon/mcp401 carriesresource_metadata=per RFC 9728 / MCP 2025-06-18 authorization spec.Slash-collapse middleware strips leading
//fromreq.url. gbrain publishes issuer with a trailing slash (URL canonical form), so naiveissuer + \"/register\"concat in clients produces//register→ Express 404.GET /mcpreturns 200 + idle SSE stream (15s heartbeat) instead of 405. MCP 2025-06-18 §StreamableHTTP permits 405 when no SSE is offered, but ChatGPT'sopenai-mcp/1.0.0treats it asinvalid_mcp_responsefatal error. Bearer-gated so unauth probes still get the spec'd 401 challenge.DELETE /mcpreturns 405 +Allowheader instead of Express's default 404.3. Opt-in ChatGPT search/fetch shim
ChatGPT Connector mode only displays tools named exactly
searchandfetchwith specific input schemas; everything else is silently filtered client-side. With gbrain's 30+ ops surfaced raw, ChatGPT shows zero tools.agentName.startsWith('ChatGPT')triggers a two-tool mode:tools/listreturns onlysearch+fetchwithinputSchemamatching OpenAI's connector spec.tools/callrewritesfetch→get_pageand projects results viatoChatgptShape():search→{ results: [{ id, title, text, url }] }fetch→{ id, title, text, url, metadata }structuredContent(machine-read) andcontent[].text(legacy JSON string) for max compat.Other MCP clients see the full op surface unchanged.
Diagnostics
appendFileSyncedge-trace logger writes every inbound request to~/.gbrain/logs/edge-trace.logsynchronously, bypassing bun's block-buffered stdout under launchdStandardOutPath. Without this,gbrain serve --http's log file lags real activity by minutes-to-hours and live debug is blind. Cheap (one fs call per request, no formatting). Happy to gate behind--debug-edge-traceif you'd prefer it not be always-on.Codex review follow-ups (not in this PR)
External review of the diff flagged 5 items I'd address in follow-up PRs once the foundation lands:
registerClientshould not generate or store aclient_secret_hashfortoken_endpoint_auth_method='none'clients (durable fix for the PKCE secret-leak workaround)./mcpshould enforce the RFC 8707resourceindicator as token audience (currently stored, not validated).source_id:slugfor cross-source dedup safety.client_name.startsWith('ChatGPT')(DCRclient_nameis client-controlled). Better gated by--enable-chatgpt-compatflag or explicit/mcp/chatgptroute.outputSchema.Verification
End-to-end against a self-hosted production deploy:
POST /register(201) →GET /authorize(302) →POST /token(200) →oauth_tokensrow issued with both access + refresh.GET /mcpopens SSE stream with bearer;POST /mcpJSON-RPC dispatches.search+fetch;search(\"<query>\")returns{ results: [...] }populated from gbrain./tokenpre-hash +getClientstrip patches (had been silently 401'ing on every confidential-clientclient_secret_postexchange).Need help on this PR? Tag
@codesmithwith what you need.